// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
const { authenticateRequest, getAttachmentObject, getGateFileContent, getOwnedAttachment } =
vi.hoisted(() => ({
authenticateRequest: vi.fn(),
getAttachmentObject: vi.fn(),
getGateFileContent: vi.fn(),
getOwnedAttachment: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/db/pool", () => ({ getPool: () => ({}) }));
vi.mock("@/lib/auth/server", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/server")>()),
authenticateRequest,
}));
vi.mock("@/lib/db/attachments", () => ({ getOwnedAttachment }));
vi.mock("@/lib/chat/gate-files", () => ({ getGateFileContent }));
vi.mock("@/lib/storage/s3", () => ({ getAttachmentObject }));
import { GET } from "@/app/api/attachments/[id]/route";
import { AuthError } from "@/lib/auth/server";
const attachment = {
id: "00000000-0000-4000-8000-000000000077",
name: "договор 1.txt",
contentType: "text/plain",
size: 15,
gateFileId: "file-opaque_123",
objectKey: null,
};
beforeEach(() => {
authenticateRequest.mockReset().mockResolvedValue({
id: "00000000-0000-4000-8000-000000000001",
email: "alice@example.com",
});
getOwnedAttachment.mockReset().mockResolvedValue(attachment);
getGateFileContent.mockReset().mockResolvedValue(streamOf("private contents"));
getAttachmentObject.mockReset().mockResolvedValue(streamOf("legacy contents"));
});
describe("GET /api/attachments/:id", () => {
it("streams Gate content through the authenticated same-origin route", async () => {
const request = new Request(`http://localhost/api/attachments/${attachment.id}`, {
headers: { Authorization: "Bearer exact-user-jwt" },
});
const response = await GET(request, { params: Promise.resolve({ id: attachment.id }) });
expect(response.status).toBe(200);
expect(response.headers.get("Location")).toBeNull();
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(response.headers.get("Content-Type")).toBe("text/plain");
expect(response.headers.get("Content-Length")).toBe("15");
expect(response.headers.get("Content-Disposition")).toBe(
"attachment; filename*=UTF-8''%D0%B4%D0%BE%D0%B3%D0%BE%D0%B2%D0%BE%D1%80%201.txt",
);
expect(await response.text()).toBe("private contents");
expect(getGateFileContent).toHaveBeenCalledWith(
"file-opaque_123",
"Bearer exact-user-jwt",
expect.any(AbortSignal),
);
expect(getAttachmentObject).not.toHaveBeenCalled();
});
it("keeps legacy object-key rows downloadable during migration", async () => {
getOwnedAttachment.mockResolvedValue({
...attachment,
gateFileId: null,
objectKey: "legacy/private-key",
});
const response = await GET(
new Request(`http://localhost/api/attachments/${attachment.id}`, {
headers: { Authorization: "Bearer exact-user-jwt" },
}),
{ params: Promise.resolve({ id: attachment.id }) },
);
expect(await response.text()).toBe("legacy contents");
expect(getAttachmentObject).toHaveBeenCalledWith("legacy/private-key");
expect(getGateFileContent).not.toHaveBeenCalled();
});
it("does not resolve content when the caller is unauthorized", async () => {
authenticateRequest.mockRejectedValue(new AuthError());
const response = await GET(new Request("http://localhost/api/attachments/id"), {
params: Promise.resolve({ id: "id" }),
});
expect(response.status).toBe(401);
expect(getOwnedAttachment).not.toHaveBeenCalled();
expect(getGateFileContent).not.toHaveBeenCalled();
expect(getAttachmentObject).not.toHaveBeenCalled();
});
it("does not resolve content for another user's attachment", async () => {
getOwnedAttachment.mockResolvedValue(null);
const response = await GET(
new Request("http://localhost/api/attachments/id", {
headers: { Authorization: "Bearer exact-user-jwt" },
}),
{ params: Promise.resolve({ id: "id" }) },
);
expect(response.status).toBe(404);
expect(getGateFileContent).not.toHaveBeenCalled();
expect(getAttachmentObject).not.toHaveBeenCalled();
});
});
function streamOf(content: string): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(content));
controller.close();
},
});
}